Bugfix: swap="outerHTML" on <div> with style attribute leaves htmx-swapping class behind#3341
Conversation
… it failes, implemented initial fix in htmx.js that makes (all) test(s) run and pass.
…sts were passing and I know why, though. This means I miss one more regression test for the bug in cloneAttributes.
…butes after I noted they could as well be removed by mergeTo.setAttribute in the second forEach loop.
|
I accidentally forked the Fortunately, it was just the line 1992 fix that needed to be moved inside the |
|
Yeah I recently did a deep dive of the cloneAttributes function when looking into an issue #412 where class attributes can be updated by third party libraries during the default settle delay which htmx then reverts breaking things. But I couldn't find a good solution to that problem. But I did find some of the same things you found about cloneAttributes. It does indeed remove all htmx related classes during this phase and I found some of these were later checked for and removed if they were still there. However I came to the conclusion that the after settle clone attributes call is meant to set the class to its final state which includes removing all the classes so later explicit removals are normally not needed (unless you disable class attribute settling). I don't think their is really a requirement that classes are kept on the elements at this stage as this is during the final stage of the settle and they are no longer needed here. But iterating on live collection is a really good catch as this can be a real pain. I think we can simplify your fix down to just: function cloneAttributes(mergeTo, mergeFrom) {
forEach(Array.from(mergeTo.attributes), function(attr) {
if (!mergeFrom.hasAttribute(attr.name) && shouldSettleAttribute(attr.name)) {
mergeTo.removeAttribute(attr.name)
}
})
forEach(mergeFrom.attributes, function(attr) {
if (shouldSettleAttribute(attr.name)) {
mergeTo.setAttribute(attr.name, attr.value)
}
})
}Array.from() converts them to a stable array for us to iterate on safely and this passes the tests fine for me. The mergeFrom.attributes I found is actually stable here so it does not actually need to be copied to an array like the mergeTo does above. With this one line change applied I was now unable to reproduce the issue and tests all passed fine. Also did some manual testing and tried with attributesToSettle config set to not process class attribute which could cause issues but this worked fine as well and had no issue with retained htmx classes. I was unable to now reproduce the issue where swapping class was still on the new outerHTML replaced target that you added the extra remove class for all settle elements so I don't think we need this extra fix line you added at 1992 now either. Anyway if you can update this PR I think this will be a great fix thanks. |
|
Sorry for responding so late, busy days. Actually, I had the chance to read your response on saturday but I wasn't able to sit down and do the thing until now. I see what you're saying, I just applied the changes and rerun the tests myself just to be sure. Funny how it boils down to a one liner :) |
Description
I stumbled upon an unexpected behavior a couple days ago while using HTMX version 1.9.10, but I found it happens in 1.9.12 and 2.0.4 as well.
Explanation by example
To explain it with an extremely stripped-down example, let's suppose you have a body like this:
Notice I'm trying to swap the
div#test-divwith anouterHTMLswap strategy, and thediv#test-divhas astyleattribute. All it takes is the attribute - there is no need for a string with actual style in it to be there.Let's suppose the response from
/testis this:We should expect the resulting body to look like this:
But actually, it looks like this:
An
htmx-swappingclass is left behind.The first regression test I added covers this case, and it fails if no changes are made to htmx.js (which is expected).
Root cause
I digged down in the source code using the debugger and found what seemed to be the root cause of the problem:
htmx/src/htmx.js
Line 1972 in d1d0cd9
This line of code removes the
htmx-swappingclass from thetargetelement, which is the original element in the DOM we're swapping. This works forinnerHTMLswaps because the node is still the same, but won't affect the new node in case of anouterHTMLswap.This is easily fixed by removing the
htmx-swappingclass from the new nodes too, which are insettleInfo.elts. This justifies the line 1992 I added. I'm pretty positive this is correct, but since I'm not an expert about the HTMX codebase, despite all tests are passing, I'm asking the maintainers to review this and confirm it won't have any unexpected side effects.Why does the swap work when there's no
styleattribute on the original node, though?Assume, from now on, I didn't implemented the line 1992 fix yet, because we need the code in the original state to keep debugging.
I removed
stylefrom the original node and scanned the code with the debugger to find wherehtmx-swappingwas being removed. For the sake of clarity, I mean that we're now starting with this body:Notice no
styleattribute is on thediv. Assume the endpoint response remains the same as before.Turns out, the
htmx-swappingclass is removed during the secondcloneAttributescall. This is the originalcloneAttributesimplementation:I included the
forEachimplementation to make it easier to read.cloneAttributesis called during the swap to make the new node (temporarily) look like the original one (meaning thestyle,class,widthandheightattributes of the original node are copied on the new node, or removed entirely if the original one doesn't have them).Then it is called again (as a
settleInfo.taskselement) during the settling phase, to reset the new node to its original appearance. To do this, it leveragesnewAttributes, a clone of the new node that has been created before modifying it. This last call is wherehtmx-swappingis wiped away when there is nostyleattribute.This removal of
htmx-swappingis due to the fact that the new node, originally, did not have any class on it. In practice,newAttributesnode is exactly how/testresponded:So, when
cloneAttributesis called onnewNodeandnewAttributes, thennewNodeloses theclassattribute, since there is noclassattribute onnewAttributes.This has a side-effect: the
htmx-addedand thehtmx-settlingclasses are removed as well. This looks like a bug to me, since there are a couple function calls further down the line that explicitly remove these two classes. I'll come back to this later.How
cloneAttributesbehaves when there is thestyleattributeI then added back the
styleattribute on the original node and debugged step by step the secondcloneAttributesexecution, to see why the wiping wasn't happening in this case.What happens is that, being
mergeTo.attributesandmergeFrom.attributesNamedNodeMaps, which (as per MDN docs) are live collections, whenmergeTo.removeAttribute(attr.name)is called forstyle, themergeTo.attributesarray suddenly becomes one element shorter. On the next iteration inside theforEachfunction, the check on the length fails and theforloop is prematurely exited - the removal of the classes does not happen.As I mentioned before,
htmx-addedandhtmx-swappingare explicitly removed down the line, so this explains why onlyhtmx-swappingis left behind on the new node.I changed the
cloneAttributesfunction to first create copies ofmergeTo.attributesandmergeFrom.attributes(lines from 1415 to 1423 plus lines 1432 and 1437) so that theforloop is untouched by the changes that are being made to theNameNodeMaps. To be fair, onlymergeTo.attributesrequired such a treatment, sincemergeFromis not being mutated. But I feel this way it is more future-proof and the performance impact should be negligible.But now it's wiping all three
htmx-*classesIt seems to me that
cloneAttributesshould be aware of the fact thathtmx-*classes should be preserved: they're added by HTMX for a reason, and it's up to other parts of HTMX to get rid of them when it's done using them. So I added the lines from 1425 to 1430 and from 1443 to 1445.I initially implemented something that preserved only
htmx.config.addedClass,htmx.config.settlingClassandhtmx.config.swappingClass(which arehtmx-added,htmx-settlingandhtmx-swappingrespectively). Also, this first version of mine only preserved the classes in case they were wiped out by the firstforEachloop, but I soon realized the secondforEachcould wipe them as well. We just need to have a/testresponse that looks like this:In this case, the second
forEachwould causesetAttribute('class', 'my-class')to be called, effectively obliterating all thehtmx-*classes.The code I ended up writing is both more concise and more conceptually sound, in my opinion. I'd like to know if maintainers think the same.
How to test the
cloneAttributesfix?As it stands before the fix,
cloneAttributesdoes not remove theclassattribute if it is preceded by astyle(orwidth, orheight) attribute.So, even after the line 1992 fix, but before the
cloneAttributesfix, we can still have the buggy behavior if the original node in the body looks like this:After the response from
/testwe would find the following div in the body:instead of the expected:
The regression test for the
cloneAttributesfix covers exactly this scenario.Corresponding issue
No corresponding issue to this PR. The guidelines said that, if I already have a solution, a PR is better suited, so here I am :)
Testing
I wrote the "swap=outerHTML clears htmx-swapping class when old node has a style attribute and no class" test first, which did not pass before the fix and passes after.
I realized the fix at line 1992 is all it takes to make this test pass, so we still missed a regression test for the fix in
cloneAttributesI added the "swap=outerHTML won't carry over user-defined classes when old node has a style attribute before the class attribute" test, which did not pass after the line 1992 fix alone, but passes after the
cloneAttributesfix.I manually checked (with the debugger) that
htmx-addedandhtmx-settlingnow survive the secondcloneAttributescall and are removed, respectively, here:htmx/src/htmx.js
Line 1572 in d1d0cd9
and here:
htmx/src/htmx.js
Line 1995 in d1d0cd9
I checked this in both the possible scenarios:
forEachis the cause, i.e. when the original node does not have aclassattribute;forEachis the cause, i.e. when the original node has aclassattribute with a user-defined class.Checklist
masterfor website changes,devforsource changes)
approved via an issue
npm run test) and verified that it succeeded